Skip to content

Make 'transaction with too many inputs should be rejected' deterministic - #2455

Open
Ergologica wants to merge 1 commit into
ergoplatform:masterfrom
Ergologica:fix/deflake-too-many-inputs-2095
Open

Make 'transaction with too many inputs should be rejected' deterministic#2455
Ergologica wants to merge 1 commit into
ergoplatform:masterfrom
Ergologica:fix/deflake-too-many-inputs-2095

Conversation

@Ergologica

Copy link
Copy Markdown

Closes #2095.

The flaky test

The unstable test is property("transaction with too many inputs should be rejected"), today in ErgoNodeTransactionSpec (it was in ErgoTransactionSpec when the issue was filed).

It calibrated a wall-clock budget on the machine running it:

val Timeout: Long = {
  val hf = Blake2b256
  (1 to 5000000).foreach(i => hf(s"$i-$i"))   // "just in case to heat up JVM"
  val t0 = System.currentTimeMillis()
  (1 to 250000).foreach(i => hf(s"$i"))
  System.currentTimeMillis() - t0
}

and then asserted time0 <= Timeout for the cost-limited validation and time > Timeout for the unlimited one.

The calibration window and the two measurement windows are three separate slices of wall time. On a contended CI runner they get different shares of the CPU, so either assertion can flip without anything being wrong with the node:

  • calibration gets a full core, measurement gets preempted -> time0 > Timeout -> the first assertion fails;
  • calibration gets preempted, measurement runs clean -> time <= Timeout -> the second assertion fails.

A GC pause landing in one window and not the others does the same thing.

The fix

What actually protects the node from this transaction is the block cost limit, not the clock; the cost model exists precisely so that time does not have to be measured. So the assertions are now on cost, which is deterministic:

  • with the default parameters the transaction is rejected, and the failure is bsBlockTransactionsCost — unchanged, this assertion was already there and was never the flaky one;
  • with a cost limit high enough to let it through, validation succeeds and the cost it accumulates is more than twice the block limit. Measured today: block limit 1_000_000, full cost 4_362_200, a ratio of 4.4. The generator is called as validErgoTransactionGenTemplate(0, 0, 2000, trueLeafGen), and boxesGenTemplate is given minInputs = maxInputs = 2000, so the input count — and hence the cost — is fixed rather than sampled;
  • and it is rejected by any limit below that cost, one unit below included.

The 5,000,000-hash warm-up and the 250,000-hash calibration are gone, which also takes several seconds off the suite. BenchmarkUtil and Blake2b256 were used only here and their imports are removed.

What is no longer asserted

The old test also implied "the rejection is fast because validation aborts as soon as the limit is passed, instead of validating all 2000 inputs". I did not try to replace that with a timing ratio between the two measurements: it would be less flaky than the absolute budget but still timing-based, and a single GC pause could still invert it. The cost assertions cover the property the node depends on — the transaction costs far more than a block may spend, and any limit below its cost rejects it.

Happy to add an instrumented check of the early abort if you would rather keep that guarantee explicitly covered; it needs a hook that does not exist today.

Note on #2267

#2267 targets this issue but I don't think it can fix it: it edits ergo-core/src/test/.../ErgoTransactionSpec.scala — a file created in 2024, after the issue was filed — and swaps .toEither.left.get for a Failure/Success match on two fully deterministic tests. The timing-based property it does not touch is the unstable one.

sbt "testOnly org.ergoplatform.modifiers.mempool.ErgoNodeTransactionSpec" — 19 tests, green.

The test calibrated a wall-clock budget by timing 250K Blake2b256 hashes and then asserted that validation fit in it. The calibration window and the two measurement windows are separate slices of wall time, so on a contended CI runner they get different shares of the CPU and either assertion can flip without anything being wrong with the node.

What protects the node here is the block cost limit, not the clock, so assert on cost instead: the transaction is rejected with bsBlockTransactionsCost under the default parameters, its cost with a high enough limit is more than twice the block limit, and any limit below that cost rejects it. The input count is fixed by the generator, so the cost is stable.

Also drops the 5M-hash warm-up, taking several seconds off the suite.

Closes ergoplatform#2095
@Ergologica

Copy link
Copy Markdown
Author

On the red CI — it is not this PR.

Run node tests fails on UtxoStateSpecification: applyModifier() - valid full block:

IllegalArgumentException was thrown during property evaluation.
  Message: requirement failed
  Occurred when passed generated values (
    arg0 = BoxHolder(2000 boxes inside)
  )

The same CI round on #2456 — which touches ErgoHttpService and shares nothing with this branch — fails the same way in a different suite, DigestStateSpecification: applyModifier() - valid block. Both properties are the same three lines:

forAll(boxesHolderGen) { bh =>
  val us = createUtxoState(bh, parameters)
  ...
  val block = validFullBlock(parentOpt = None, us, bh)

So what fails is the shared block generator, which neither PR touches — this one changes a single test file, ErgoNodeTransactionSpec.scala.

requirement failed with no message is a bare require. On that path there are two, both in validUnsignedTransactionFromBoxes: ErgoNodeTransactionGenerators.scala:138 (require(inputSum >= minValue)) and :144 (require(minValue * outputsCount <= inputSum)). :144 cannot fail once :138 holds, since outputsCount <= inputSum / minValue by construction — so it is :138: the one or two boxes left at the end of validTransactionsFromBoxes are together worth less than BoxUtils.sufficientAmount, and validValueGen can draw values that low.

Not new ground, incidentally — ErgoNodeTestConstants already lowers MinValuePerByteIncrease by 30 to compensate for exactly this, citing "insufficient box value" from test randomness. Evidently not by enough.

Which is the same shape as #2095, the issue this PR closes: a property test whose generator can produce inputs the property does not hold for. Happy to open a follow-up that makes the generator satisfy the invariant by construction rather than by margin — say the word.

@Ergologica

Copy link
Copy Markdown
Author

One more thing a reviewer should not have to discover on their own: #2267 already exists for #2095, opened in December, and I did not see it before opening this.

They may not actually be rivals. #2095 names the suite generically ("Sometimes ErgoTransactionSpec fails"), and that suite has since been split in two:

  • ergo-core/src/test/.../modifiers/mempool/ErgoTransactionSpec.scala
  • src/test/.../modifiers/mempool/ErgoNodeTransactionSpec.scala

#2267 touches the first, rewriting two assertions in the context extension with neg / neg and pos ids properties from txTry.toEither.left.get.isInstanceOf[X] to a match with an explicit fail(...). That is a real improvement to the failure message — .left.get on a Success throws a bare NoSuchElementException and tells you nothing — though the pass/fail behaviour is the same either way.

This PR touches the second, and one property in it: transaction with too many inputs should be rejected, which warmed up with 5M hashes, calibrated against 250K more, and then asserted on wall-clock time. That is the part that fails on a loaded CI runner, and it is replaced here with an assertion on the measured cost against the block limit (1,000,000 vs 4,362,200 — a 4x margin, no timing).

So as far as I can tell the two changes are complementary rather than competing, and both could land. I have no view on how the bounty should be split, and I would rather say this up front than have it surface later — @glasgowm148 for visibility, since the reservation is filed as ErgoDevs/Ergo-Bounties#54.

@Ergologica

Copy link
Copy Markdown
Author

Correction to my comment above: the cause I named for the Run node tests failure is wrong. I went to fix it and could not reproduce it, so here is what I actually measured.

I said the bare require(inputSum >= minValue) at ErgoNodeTransactionGenerators.scala:138 was being hit. It is not. Running validTransactionsFromBoxHolder(boxesHolderGen.sample.get, new RandomWrapper) 300 times: 0 failures. Running the whole UtxoStateSpecification property body — hold, createUtxoState, validFullBlock(parentOpt = None, ...), applyModifier(...).get — 500 times: 0 failures. The arithmetic also says so: sufficientAmount is 1,351,680 and validValueGen draws uniformly from [800, 9.5e9], so two boxes falling short together is a ~1e-8 event, not a per-CI-run one. I should have run that before posting a line number.

What I did find while trying, which is reproducible and measurable:

createUtxoState leaks about 8 file descriptors per call, and nothing in the test tree ever closes them. ErgoState.closeStorage() exists, but the only callers are ErgoNodeViewHolder; no test calls it. Measured over 40 calls, on master:

leaked: base=148 after=472  delta=324  -> 8.1 fds per createUtxoState
closed: base=472 after=472  delta=0    -> 0   fds, calling us.closeStorage()

My 500-iteration loop did not finish — it died at roughly 450 with java.nio.file.FileSystemException: Too many open files against a 4096 limit. In the test tree there are ~124 createUtxoState / createTempDir call sites and 14 properties doing forAll(boxesHolderGen), each at ScalaTest's default minSuccessful = 10. A full Run node tests job holds all of that open in one JVM until it exits.

I want to be careful not to make the same mistake twice, so: this is a real leak that I have measured, and a plausible mechanism for a failure that lands in a different suite each run — but I have not traced it to the requirement failed you see in the log, and I am not claiming it is the cause. What still stands from the comment above is only the part that is directly observable: the failure hits forAll(boxesHolderGen) properties in suites this PR does not touch, and it hit two different suites across two unrelated branches.

Happy to open a separate PR for the descriptor leak — it is self-contained and easy to verify with the numbers above — if that is wanted. It is independent of this one.

@Ergologica

Copy link
Copy Markdown
Author

Opened the descriptor-leak fix as #2460 rather than leaving it as an offer. It is independent of this PR and does not touch anything here.

Peak open descriptors for UtxoStateSpecification + DigestStateSpecification, sampled from /proc/<pid>/fd: 2429 on master, 1019 on that branch, from 20 added lines. Whether it also settles the intermittent Run node tests failure I genuinely do not know — I said as much there too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ErgoTransactionSpec is unstable

1 participant